iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0

當 CLI 命令需要與外部服務(例如 REST API)互動時,若將網路請求直接寫死在命令內部,命令就會與外部環境高度耦合。要讓 CLI 具備良好的可測試性,關鍵在於將命令邏輯與外部相依解耦。

以一個查詢使用者資料的 user <id> 命令為例,執行時會向遠端服務取得資料並輸出名稱:

$ go run . user 1
Evan

底層發送請求至 API endpoint GET /users/{id},接收回傳的 JSON 資料:

{
  "id": "1",
  "name": "Evan"
}

若命令直接建立 HTTP client 並連線至真實服務,測試就會受到網路延遲、遠端資料異動或伺服器斷線影響,也無法穩定驗證 API 回傳 500 錯誤或 Ctrl+C 取消連線時的處理邏輯。

這篇將透過介面(Interface)與依賴注入(Dependency Injection),把外部連線從命令中解耦,在單元測試時能自由替換底層實作。範例專案位於 cli-sample/cli-dependency-testing/。

透過介面隔離 CLI 命令與外部服務

在 cmd/user.go 中,我們不讓 user 命令直接建立 HTTP client,而是先宣告 UserClient 介面作為命令所需的資料來源:

type User struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type UserClient interface {
	GetUser(ctx context.Context, id string) (User, error)
}

這個介面建立了命令與資料提供者之間的Interface:只要型別實作了相同的 GetUser 方法,就能傳給命令使用。

https://ithelp.ithome.com.tw/upload/images/20261010/20111896vjrMQ8AXd8.png

為了方便測試,建立 userOptions 儲存 UserClient、輸出目標 io.Writer 與解析後的 ID:

type userOptions struct {
	Client UserClient
	Out    io.Writer
	ID     string
}

runUser 只透過 opts.Client.GetUser 取得使用者,再把名字寫到 opts.Out。不需要知道傳進來的是真正的 HTTP client 還是測試用的 Mock:

func runUser(ctx context.Context, opts *userOptions) error {
	user, err := opts.Client.GetUser(ctx, opts.ID)
	if err != nil {
		return fmt.Errorf("get user %q: %w", opts.ID, err)
	}

	_, err = fmt.Fprintln(opts.Out, user.Name)
	return err
}

newUserCmd 建立 Cobra 命令,接收 UserClient 介面與 out 輸出目標,並在 RunE 中將 CLI 接收到的 args 帶入 runUser 執行:

// newUserCmd 接收可替換的 UserClient 介面與輸出目標
func newUserCmd(client UserClient, out io.Writer) *cobra.Command {
	opts := &userOptions{Client: client, Out: out}
	return &cobra.Command{
		Use:   "user <id>",
		Short: "Get a user from the configured REST API",
		Args:  cobra.ExactArgs(1),
		RunE: func(command *cobra.Command, args []string) error {
			// 將 CLI 傳入的參數設定給 opts,並帶入 command.Context 執行業務邏輯
			opts.ID = args[0]
			return runUser(command.Context(), opts)
		},
	}
}

透過這樣的分工,Cobra 只負責參數驗證與 CLI 命令設定,runUser 則專心處理「查詢使用者並寫回輸出」的業務邏輯。

用 Mock 替換外部介面進行測試

接下來在 cmd/user_test.go 中建立實作 UserClient 介面的 mockUserClient。讓 mockUserClient 包含 Testify 的 mock.Mock,即可在 GetUser 內呼叫 m.Called(ctx, id) 記錄傳入參數並取得回傳值:

// mockUserClient 實作 UserClient 介面,透過 Testify mock 記錄呼叫與回傳值
type mockUserClient struct {
	mock.Mock
}

func (m *mockUserClient) GetUser(ctx context.Context, id string) (User, error) {
	// 記錄傳入的 context 與 id 參數
	args := m.Called(ctx, id)
	// 取得預先設定的第一個回傳值 User 與第二個回傳值 error
	user, _ := args.Get(0).(User)
	return user, args.Error(1)
}

接著在測試時,設定 mockUserClient 收到特定 ID 時的回傳值,並驗證 runUser 是否正確處理資料與格式化輸出:

func TestRunUser(t *testing.T) {
	// 設定當 GetUser 收到 ID "1" 時,回傳使用者 Evan(第一個 context 參數使用 mock.Anything 忽略比對)
	client := new(mockUserClient)
	client.On("GetUser", mock.Anything, "1").
		Return(User{ID: "1", Name: "Evan"}, nil).
		Once()

	var stdout bytes.Buffer
	opts := &userOptions{Client: client, Out: &stdout, ID: "1"}

	// 執行被測函式並驗證輸出內容
	require.NoError(t, runUser(context.Background(), opts))
	assert.Equal(t, "Evan\n", stdout.String())

	// 驗證 GetUser 是否確實被呼叫且參數符合預期
	client.AssertExpectations(t)
}

Once() 要求 GetUser 只能被呼叫一次,AssertExpectations 則確認測試結束前確實發生了這次呼叫。

驗證外部服務失敗與取消

使用者執行 user 1 時,遠端 API 可能因維護或過載回傳 500 Internal Server Error。client 會把這個狀態轉成 error,runUser 則要補上正在查詢的使用者 ID,讓最外層收到完整的錯誤訊息:

user, err := opts.Client.GetUser(ctx, opts.ID)
if err != nil {
	return fmt.Errorf("get user %q: %w", opts.ID, err)
}

單元測試不需要等待遠端服務真的出錯。將 mock 設定成回傳 HTTP 500 對應的 error,就能確認 runUser 回傳的訊息包含 ID,而且失敗時不會把不完整的使用者資料寫到 stdout:

func TestRunUserReturnsClientError(t *testing.T) {
	// 設定 mock 在收到 GetUser("1") 時模擬 HTTP 500 錯誤回傳
	client := new(mockUserClient)
	client.On("GetUser", mock.Anything, "1").
		Return(User{}, errors.New("unexpected HTTP status 500 Internal Server Error")).
		Once()

	var stdout bytes.Buffer
	opts := &userOptions{Client: client, Out: &stdout, ID: "1"}

	// 執行被測函式
	err := runUser(context.Background(), opts)

	// 驗證錯誤訊息包含 ID 上下文與原始錯誤,且失敗時 stdout 未寫入資料
	require.ErrorContains(t, err, `get user "1": unexpected HTTP status 500 Internal Server Error`)
	assert.Empty(t, stdout.String())
	client.AssertExpectations(t)
}

使用者在等待 API 回應時按下 Ctrl+C,傳給 Command 的 context 會被取消。GetUser 收到同一個 context,底層停止等待後回傳 context.Canceled。runUser 使用 %w 包裝錯誤,因此最外層仍能用 errors.Is 判斷取消原因:

func TestRunUserPreservesContextCancellation(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	client := new(mockUserClient)
	client.On("GetUser", ctx, "1").
		Return(User{}, ctx.Err()).
		Once()

	var stdout bytes.Buffer
	opts := &userOptions{Client: client, Out: &stdout, ID: "1"}

	err := runUser(ctx, opts)

	require.ErrorIs(t, err, context.Canceled)
	assert.Empty(t, stdout.String())
	client.AssertExpectations(t)
}

在 Production 注入真正的 HTTP Client

cmd/user_client.go 的 HTTPUserClient 實作 GetUser,透過 http.NewRequestWithContext 將 Command 的 context 放進 request,再使用 http.Client 發送 GET /users/{id},檢查 HTTP 狀態碼後將 JSON 解析為 User。

在 main.go 程式入口點,建立 HTTPUserClient 實例並傳給 root Command:

userClient := cmd.NewHTTPUserClient("http://localhost:8080", http.DefaultClient)
root := cmd.NewRootCmd(userClient, os.Stdout)

正式執行時傳入 *HTTPUserClient,單元測試時傳入 *mockUserClient。因為兩者皆實作 UserClient 介面,runUser 的核心邏輯不需要為了測試加入額外分支。

https://ithelp.ithome.com.tw/upload/images/20261010/20111896Xz4tX04o4B.png

透過介面隔離外部依賴的效益與權衡

把外部 API 抽象成 UserClient 介面,主要帶來三個好處:

  • 隔離網路與環境波動:單元測試不再發送真實 HTTP 請求,能在毫秒內完成,避免測試流程因為網路延遲或遠端服務斷線而失敗。
  • 穩定驗證極端與失敗情境:透過 Mock 注入 error 或 context 取消狀態,可以直接測試 HTTP 500 錯誤回應、逾時與使用者中斷(Ctrl+C)時的處理邏輯,不需要改動或模擬遠端 API 伺服器的錯誤狀態。
  • 維持 CLI 元件單一職責:runUser 專心處理資料與輸出格式,Cobra 處理參數與命令,HTTPUserClient 專心處理 HTTP 請求發送與 JSON 解析,各元件的單元測試各自獨立。

這種設計的權衡是 Mock 只能驗證 runUser 內部的呼叫邏輯與錯誤處理,無法確保 HTTPUserClient 實作的 URL 路徑、Header 或 JSON 解析欄位是否真的與遠端 API 一致。外部 API 合約的正確性仍需留給整合測試或使用 testscript 搭配真實 API 進行驗證。

CLI 測試分層策略總結

建立可維護的 CLI 測試體系時,關鍵在於針對不同層級選用適當的驗證方式:

  • 單元測試層(Unit Tests):專注驗證命令邏輯與資料格式化。利用 io.Writer 控制 stdout/stderr 輸出,並透過介面(如 UserClient)與 Mock 物件隔絕外部 API、檔案系統或時間等相依,獲得毫秒級的執行速度與極端失敗情境的覆蓋率。
  • Command 整合測試層(Cobra 命令驗證):透過 SetArgs 與 Execute 直接測試 Cobra Command 樹,驗證參數解析、Subcommand 組合與 Flag 邏輯。
  • Process 驗證層(Process & E2E):使用 testscript 執行真實編譯出的 Binary,從獨立 Process 驗證 Exit Code、環境變數與完整 CLI 執行流程。

測試是 CLI 能持續修改、重構的基礎。把驗證依範圍分層,每次改動才能快速得到可靠的回饋,這也是 CLI 隨功能增加仍能維持品質的關鍵。


上一篇
CLI 測試:從 Command 單元測試到 Process 驗證
下一篇
CLI 系列總結
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言